そのlistとdict、ひょっとするとtupleとnamedtupleで良いかも?
こんちには。
データ事業本部 インテグレーション部 機械学習チームの中村( @nokomoro3 )です。
今回はPythonのlistとdictを変更できないデータにするために、tupleとnamedtupleを使用する方法をご紹介します。
なお、このお題は以下の書籍を参考にしています。
そもそもなぜ変更できないデータにしたいか
代入後の変更などによって、意図しない変数を変更してしまうことを避けるためが、主な理由になるかと思います。
以下の例のようにhogeを代入したfugaに変更を加えると、hogeの方にも影響がでます。
hoge = ["A", "B", "C"]
fuga = hoge
fuga.append("D")
print(fuga)
# ['A', 'B', 'C', 'D']
print(hoge)
# ['A', 'B', 'C', 'D']
これは代入した場合でも、同じオブジェクトIDを指すためになります。
print(id(fuga))
# 132487865408064
print(id(hoge))
# 132487865408064
こちらが問題になるのは、listやdictとクラスなどのオブジェクトです。
intなどの場合も代入後は同じオブジェクトIDを指しますが、intはImmutableであるため、同じオブジェクトIDを維持したまま値を変更することはできません。
hoge = 100
fuga = hoge
print(id(fuga))
# 132488504823120
print(id(hoge))
# 132488504823120
fugaの値を変更してみます。(実際には変更はできないのですが)
fuga = 200
print(id(fuga))
# 132488504826320 # オブジェクトの再作成になるためこちらだけ変わる
print(id(hoge))
# 132488504823120
実際には変更できず、オブジェクトが再作成されているのが分かります。
つまり、dictとlistもImmutableなものに置き換えれば、こういった問題が発生しなくなります。これが今回の記事の動機です。
またcopyモジュールのdeepcopyを使って代入するようにすればこの問題は確かに解決しますが、通常の代入操作を禁止することまではできないため、安全性を保証するのは難しいと考えられます。
また、dict等は計算量を工夫するためにオーバーアロケートを行っていますが、変更できないデータにすると固定長をメモリとして確保すればよいため、メモリ効率の面でも優れているようです。
なお、余談ですがintの例でhogeの方を同じ値に書き換えると、同じオブジェクトIDをまた指すようになります。
hoge = 200
print(id(fuga))
# 132488504826320
print(id(hoge))
# 132488504826320
これは200という値自体が以下のオブジェクトIDを持つからです。
print(id(200))
# 132488504826320
listをtupleにする
以下のようにするだけでOKです。
hoge = ["A", "B", "C"]
hoge = tuple(hoge)
fuga = hoge
ただしtupleにはlistで良く使うappendがありません(Immutableになったため当然なのですが)。
fuga.append("D")
# AttributeError: 'tuple' object has no attribute 'append'
なので要素の追加を行う場合は、オブジェクトの再作成を行うような書き方となります。
これはアンパックをうまく使えばできます。
fuga = tuple([*fuga, "D"])
print(fuga)
# ('A', 'B', 'C', 'D')
tupleはlistと同じようにスライスすることが可能です。
print(fuga[1:])
# ('B', 'C', 'D')
アンパックもできます。
print(*fuga)
# A B C D
dictをnamedtupleにする
少し面倒なのですが、以下のように最初に名前付きタプルの型のようなものを作ってあげる必要があります。
hoge = {"aaa": 100, "bbb": 200, "ccc": 300}
from collections import namedtuple
Hoge = namedtuple("Hoge", hoge.keys())
hoge = Hoge(**hoge)
print(hoge)
# Hoge(aaa=100, bbb=200, ccc=300)
namedtupleの場合、dictでよくやるキーと値の追加ができなくなります。
hoge["ddd"] = 400
# TypeError: 'Hoge' object does not support item assignment
値へのアクセス方法もかわり、dictのような以下のアクセスはできません。
print(hoge["aaa"])
# TypeError: tuple indices must be integers or slices, not str
代わりのドットでアクセスすることができます。
print(hoge.aaa)
# 100
キーと値を追加するには名前付きタプルの型を再定義するところから必要です。
Hoge = namedtuple("Hoge", [*hoge._fields, "ddd"])
hoge = Hoge(**hoge._asdict(), ddd=400)
print(hoge)
# Hoge(aaa=100, bbb=200, ccc=300)
_asdict()
でdict型に変更ができますので、それをうまく使います。
また fields
でキー一覧を取得できます。
またnamedtupleのアンパック **
は使えないので、 _asdict()
を介する必要があります。
namedtupleの詳細は以下もご確認ください。
複雑なデータクラスを書き換えるの面倒では
とここまで書いてきましたが、実際にもう少し複雑な階層構造をもつデータクラスなどを使い始めると、この辺りを実装するのも大変になります。
この場合は、標準ライブラリではないのですがPydanticの導入などが選択肢となります。
Pydanticのモデルで frozen=True
とすることにより、Immutableにすることができるので、こちらの方が実用上の汎用性は高いと思います。
Pydanticであればデータ型のバリデーションも可能なため、こちらも機会があれば紹介したいと思います。
まとめ
こちらのようなお話を詳しく知りたい方は、ぜひ「エキスパートPythonプログラミング」の方を読んでみることをオススメします。
本記事が皆様のご参考になれば幸いです。